--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 0e0732a1d1122220f5aa0b6285329b130f1c900f
Parents : af2eff2
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T21:14:31-05:00
feat(page-handling): update nomadnet browser renderer and page management by adding support for .md, .txt, and .html extensions, implement filename normalization, and improve error handling for unsupported extensions
Changes
10 files changed, 568 insertions(+), 32 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index abd4d6ba..e0252c39 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7399,7 +7399,10 @@ class ReticulumMeshChat:
return web.json_response(
{"message": "Page name is required"}, status=400
)
- saved_name = node.add_page(name, content)
+ try:
+ saved_name = node.add_page(name, content)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
return web.json_response({"name": saved_name, "message": "Page saved"})
@routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}")
@@ -9531,6 +9534,10 @@ class ReticulumMeshChat:
response.headers["Content-Type"] = "application/wasm"
elif path.endswith(".html"):
response.headers["Content-Type"] = "text/html; charset=utf-8"
+ elif path.endswith(".md"):
+ response.headers["Content-Type"] = "text/markdown; charset=utf-8"
+ elif path.endswith(".txt"):
+ response.headers["Content-Type"] = "text/plain; charset=utf-8"
elif path.endswith(".opus"):
response.headers["Content-Type"] = "audio/opus"
elif path.endswith(".ogg"):
diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
index 6c0dcb58..523a24fb 100644
--- a/meshchatx/src/backend/page_node.py
+++ b/meshchatx/src/backend/page_node.py
@@ -9,6 +9,8 @@ request/response with specific path conventions).
Clients link to the destination and call link.request("/page/name.mu")
to fetch a page, or /file/name for files.
+
+Supported page filename extensions are ``.mu``, ``.md``, ``.txt``, and ``.html``.
"""
import json
@@ -21,6 +23,27 @@ APP_NAME = "nomadnetwork"
ASPECT = "node"
DEFAULT_INDEX = "index.mu"
+ALLOWED_PAGE_EXTENSIONS = frozenset({".mu", ".md", ".txt", ".html"})
+
+
+def normalize_page_filename(name: str) -> str:
+ """Return a safe basename with an allowed extension. Unknown extensions raise ValueError."""
+ name = os.path.basename((name or "").strip())
+ if not name or name in (".", ".."):
+ raise ValueError("page name is required")
+ lower = name.lower()
+ for ext in ALLOWED_PAGE_EXTENSIONS:
+ if lower.endswith(ext):
+ return name
+ if "." in name:
+ raise ValueError("unsupported page extension")
+ return f"{name}.mu"
+
+
+def is_allowed_page_filename(name: str) -> bool:
+ lower = os.path.basename(name or "").lower()
+ return any(lower.endswith(ext) for ext in ALLOWED_PAGE_EXTENSIONS)
+
class PageNode:
"""A single page-serving node on the Reticulum mesh."""
@@ -134,8 +157,11 @@ class PageNode:
if not os.path.isdir(self.pages_dir):
return
for fname in os.listdir(self.pages_dir):
- if os.path.isfile(os.path.join(self.pages_dir, fname)):
- self._register_page_handler(fname)
+ if not os.path.isfile(os.path.join(self.pages_dir, fname)):
+ continue
+ if not is_allowed_page_filename(fname):
+ continue
+ self._register_page_handler(fname)
def _register_existing_files(self):
"""Scan files directory and register a handler for each file."""
@@ -230,10 +256,8 @@ class PageNode:
return responder
def add_page(self, name, content):
- """Write a Micron page and register its request handler."""
- name = os.path.basename(name)
- if not name.endswith(".mu"):
- name += ".mu"
+ """Write a page file and register its request handler."""
+ name = normalize_page_filename(name)
page_path = os.path.join(self.pages_dir, name)
if isinstance(content, str):
content = content.encode("utf-8")
@@ -245,7 +269,10 @@ class PageNode:
def remove_page(self, name):
"""Remove a page and deregister its request handler."""
- name = os.path.basename(name)
+ try:
+ name = normalize_page_filename(name)
+ except ValueError:
+ return False
page_path = os.path.join(self.pages_dir, name)
if os.path.isfile(page_path):
os.remove(page_path)
@@ -261,11 +288,15 @@ class PageNode:
f
for f in os.listdir(self.pages_dir)
if os.path.isfile(os.path.join(self.pages_dir, f))
+ and is_allowed_page_filename(f)
)
def get_page_content(self, name):
"""Read and return a page's content."""
- name = os.path.basename(name)
+ try:
+ name = normalize_page_filename(name)
+ except ValueError:
+ return None
page_path = os.path.join(self.pages_dir, name)
if not os.path.isfile(page_path):
return None
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index d134334a..106e07d8 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -321,12 +321,12 @@
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
- <pre
+ <div
v-else
- v-memo="[renderedNodePageHtml]"
- class="h-full break-words whitespace-pre-wrap"
+ v-memo="[renderedNodePageHtml, nodePagePath, isShowingNodePageSource]"
+ :class="nomadPageContentClasses"
v-html="renderedNodePageHtml"
- ></pre>
+ ></div>
<!-- eslint-enable vue/no-v-html -->
</div>
@@ -396,6 +396,7 @@
<script>
import MicronParser from "../../js/MicronParser";
+import { renderNomadPageByPath } from "../../js/NomadPageRenderer";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
@@ -526,6 +527,31 @@ export default {
}
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
+ nomadPageContentClasses() {
+ if (!this.nodePagePath || this.isShowingNodePageSource) {
+ return ["h-full", "break-words", "whitespace-pre-wrap", "text-gray-100"];
+ }
+ const [p] = this.nodePagePath.split("`");
+ const pl = (p || "").toLowerCase();
+ const isRich = pl.endsWith(".mu") || pl.endsWith(".md") || pl.endsWith(".html");
+ const isHtml = pl.endsWith(".html");
+ const isMd = pl.endsWith(".md");
+ const classes = ["h-full", "break-words"];
+ if (isRich) {
+ classes.push("nomad-page-rich");
+ } else {
+ classes.push("whitespace-pre-wrap");
+ }
+ if (isHtml) {
+ classes.push("nomad-page-html-host");
+ } else {
+ classes.push("text-gray-100");
+ }
+ if (isMd) {
+ classes.push("nomad-markdown-host");
+ }
+ return classes;
+ },
},
watch: {
renderedNodePageHtml(newVal, oldVal) {
@@ -1240,18 +1266,11 @@ export default {
renderPageContent(path, content) {
// render page content if we aren't viewing source
if (!this.isShowingNodePageSource) {
- // check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [pagePathWithoutData] = path.split("`");
-
- // convert micron to html if page ends with .mu extension
- if (pagePathWithoutData.endsWith(".mu")) {
- const muParser = new MicronParser();
- return muParser.convertMicronToHtml(content, this.pagePartials);
- }
+ return renderNomadPageByPath(pagePathWithoutData, content, this.pagePartials, MicronParser);
}
- // otherwise, we will just serve the raw content, making sure to prevent injecting html
return content
.replace(/&/g, "&")
.replace(/</g, "<")
@@ -1835,4 +1854,83 @@ pre.text-wrap > div > :last-child {
color: inherit;
box-sizing: content-box;
}
+
+.nomad-markdown-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-markdown-host .nomad-markdown {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.nomad-markdown-host .nomad-markdown table {
+ white-space: normal;
+}
+
+.nomad-markdown-host .nomad-markdown h1 {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+ font-weight: 700;
+ margin: 0.75rem 0 0.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown h2 {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ font-weight: 700;
+ margin: 0.65rem 0 0.45rem;
+}
+
+.nomad-markdown-host .nomad-markdown h3 {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.55rem 0 0.4rem;
+}
+
+.nomad-markdown-host .nomad-markdown h4 {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+ font-weight: 600;
+ margin: 0.5rem 0 0.35rem;
+}
+
+.nomad-markdown-host .nomad-markdown h5,
+.nomad-markdown-host .nomad-markdown h6 {
+ font-size: 1rem;
+ line-height: 1.5rem;
+ font-weight: 600;
+ margin: 0.45rem 0 0.3rem;
+}
+
+.nomad-markdown-host .nomad-markdown p {
+ margin: 0.4rem 0;
+}
+
+.nomad-markdown-host .nomad-markdown ul,
+.nomad-markdown-host .nomad-markdown ol {
+ margin: 0.4rem 0;
+ padding-left: 1.5rem;
+}
+
+.nomad-markdown-host .nomad-markdown blockquote {
+ margin: 0.5rem 0;
+ padding-left: 0.75rem;
+ border-left: 3px solid rgb(107 114 128);
+}
+
+.nomad-markdown-host .nomad-markdown pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-x: auto;
+}
+
+.nomad-page-html-host {
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+.nomad-page-html-host .nomad-html-root {
+ color: rgb(229 231 235);
+}
</style>
diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 9dbb7bc4..9327d4f9 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -51,6 +51,29 @@ export default class MicronParser extends BaseMicronParser {
return false;
}
+ /**
+ * When false, forceMonospace can render a whole word in one span (Latin/Cyrillic/etc.),
+ * avoiding one DOM node per character (critical for large pages and resize performance).
+ */
+ static lineNeedsPerCharCells(line) {
+ if (!line) {
+ return false;
+ }
+ for (const char of line) {
+ if (MicronParser.isWideMonospaceCell(char)) {
+ return true;
+ }
+ const cp = char.codePointAt(0);
+ if (cp >= 0x2500 && cp <= 0x257f) {
+ return true;
+ }
+ if (cp >= 0x2580 && cp <= 0x259f) {
+ return true;
+ }
+ }
+ return false;
+ }
+
static stripOverlayStyles(html) {
if (typeof html !== "string") return html;
const dangerousProps = ["zindex", "inset", "top", "left", "right", "bottom", "transform"];
@@ -121,6 +144,14 @@ export default class MicronParser extends BaseMicronParser {
row-gap: 0;
gap: 0;
}
+ .Mu-mnt-group {
+ display: inline;
+ font-family: ui-monospace, monospace, "Courier New", monospace;
+ white-space: pre;
+ text-decoration: inherit;
+ vertical-align: baseline;
+ line-height: 1.25;
+ }
`;
document.head.appendChild(styleEl);
}
@@ -365,6 +396,12 @@ export default class MicronParser extends BaseMicronParser {
}
forceMonospace(line) {
+ if (line == null || line === "") {
+ return "";
+ }
+ if (!MicronParser.lineNeedsPerCharCells(line)) {
+ return "<span class='Mu-mnt-group'>" + escapeHtmlForFallback(line) + "</span>";
+ }
let out = "";
let charArr;
try {
@@ -378,7 +415,7 @@ export default class MicronParser extends BaseMicronParser {
}
for (let char of charArr) {
const cellClass = MicronParser.isWideMonospaceCell(char) ? "Mu-mnt-full" : "Mu-mnt";
- out += "<span class='" + cellClass + "'>" + char + "</span>";
+ out += "<span class='" + cellClass + "'>" + escapeHtmlForFallback(char) + "</span>";
}
return out;
}
diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
new file mode 100644
index 00000000..1f8e7361
--- /dev/null
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -0,0 +1,214 @@
+import DOMPurify from "dompurify";
+import { marked } from "marked";
+
+marked.setOptions({
+ gfm: true,
+ breaks: true,
+});
+
+const FORBID_TAGS = [
+ "script",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "base",
+ "meta",
+ "form",
+ "input",
+ "button",
+ "textarea",
+ "select",
+ "option",
+ "video",
+ "audio",
+ "source",
+ "track",
+ "picture",
+];
+
+function escapeHtmlText(text) {
+ return String(text)
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export function escapeNomadPlainText(content) {
+ return `<div class="whitespace-pre-wrap text-gray-100">${escapeHtmlText(content)}</div>`;
+}
+
+const NOMAD_HTML_ROOT_CLASS = "nomad-html-root";
+
+function normalizeMarkdownInput(md) {
+ if (md == null || md === "") {
+ return "";
+ }
+ let s = String(md);
+ s = s.replace(/\r\n/g, "\n");
+ // CommonMark requires a space after # for ATX headings; "#Title" is not a heading
+ s = s.replace(/^(\s{0,3})(\#{1,6})([^\s#])/gm, "$1$2 $3");
+ return s;
+}
+
+function rewriteCssBodyHtmlSelectors(css) {
+ if (!css) {
+ return "";
+ }
+ let s = stripExternalFromCss(css);
+ s = s.replace(/\bhtml\s*\{/g, `.${NOMAD_HTML_ROOT_CLASS} {`);
+ s = s.replace(/\bbody\s*\{/g, `.${NOMAD_HTML_ROOT_CLASS} {`);
+ s = s.replace(/\bhtml\s*,/g, `.${NOMAD_HTML_ROOT_CLASS},`);
+ s = s.replace(/\bbody\s*,/g, `.${NOMAD_HTML_ROOT_CLASS},`);
+ s = s.replace(/\bhtml\s+body\b/g, `.${NOMAD_HTML_ROOT_CLASS}`);
+ return s;
+}
+
+function stripExternalFromCss(css) {
+ if (!css) {
+ return "";
+ }
+ let s = css;
+ s = s.replace(/@import\s+[^;]+;/gi, "");
+ s = s.replace(/@import\s+url\s*\([^)]+\)\s*;?/gi, "");
+ s = s.replace(/expression\s*\(/gi, "blocked(");
+ s = s.replace(/javascript\s*:/gi, "blocked:");
+ s = s.replace(/-moz-binding/gi, "blocked-binding");
+ s = s.replace(/url\s*\(\s*["']?(?:https?:|\/\/)/gi, "url(blocked:");
+ return s;
+}
+
+function isAllowedNomadHref(href) {
+ if (href == null || href === "") {
+ return false;
+ }
+ const h = href.trim();
+ if (h.startsWith("#")) {
+ return true;
+ }
+ if (h.startsWith(":")) {
+ return true;
+ }
+ if (/^[a-f0-9]{32}:/i.test(h)) {
+ return true;
+ }
+ if (h.startsWith("/page/") || h.startsWith("/file/")) {
+ return true;
+ }
+ return false;
+}
+
+function isAllowedImgSrc(src) {
+ if (src == null || src === "") {
+ return false;
+ }
+ const s = src.trim();
+ return /^data:image\/(png|gif|jpeg|jpg|webp|svg\+xml)/i.test(s);
+}
+
+let nomadPurifyHooksInstalled = false;
+
+function ensureNomadPurifyHooks() {
+ if (nomadPurifyHooksInstalled) {
+ return;
+ }
+ nomadPurifyHooksInstalled = true;
+ DOMPurify.addHook("uponSanitizeElement", (node) => {
+ if (node.nodeName === "STYLE" && node.textContent) {
+ node.textContent = stripExternalFromCss(node.textContent);
+ }
+ });
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
+ const h = node.getAttribute("href");
+ if (!isAllowedNomadHref(h)) {
+ node.removeAttribute("href");
+ }
+ }
+ if (node.nodeName === "IMG" && node.hasAttribute("src")) {
+ const s = node.getAttribute("src");
+ if (!isAllowedImgSrc(s)) {
+ node.removeAttribute("src");
+ }
+ }
+ if (node.hasAttributes) {
+ const attrs = node.attributes;
+ for (let i = attrs.length - 1; i >= 0; i--) {
+ const a = attrs[i].name;
+ if (a && a.toLowerCase().startsWith("on")) {
+ node.removeAttribute(a);
+ }
+ }
+ }
+ });
+}
+
+function basePurifyConfig() {
+ return {
+ FORBID_TAGS,
+ ADD_TAGS: ["style"],
+ ADD_ATTR: ["class", "id", "title", "colspan", "rowspan", "align", "start"],
+ };
+}
+
+export function sanitizeNomadHtmlFragment(html) {
+ ensureNomadPurifyHooks();
+ return DOMPurify.sanitize(html, {
+ ...basePurifyConfig(),
+ WHOLE_DOCUMENT: false,
+ });
+}
+
+export function sanitizeNomadHtmlDocument(html) {
+ ensureNomadPurifyHooks();
+ let bodyMarkup = html;
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, "text/html");
+ const styles = [...doc.querySelectorAll("style")]
+ .map((el) => rewriteCssBodyHtmlSelectors(el.textContent || ""))
+ .filter(Boolean)
+ .join("\n");
+ const body = doc.body;
+ if (body) {
+ bodyMarkup = (styles ? `<style>${styles}</style>` : "") + body.innerHTML;
+ }
+ } catch {
+ bodyMarkup = html;
+ }
+ const wrapped = `<div class="${NOMAD_HTML_ROOT_CLASS}">${bodyMarkup}</div>`;
+ return DOMPurify.sanitize(wrapped, {
+ ...basePurifyConfig(),
+ WHOLE_DOCUMENT: false,
+ });
+}
+
+export function renderNomadMarkdown(markdown) {
+ const raw = marked.parse(normalizeMarkdownInput(markdown ?? ""));
+ const inner = sanitizeNomadHtmlFragment(raw);
+ return `<div class="nomad-markdown">${inner}</div>`;
+}
+
+export function renderNomadHtmlPage(html) {
+ return sanitizeNomadHtmlDocument(html);
+}
+
+export function renderNomadPageByPath(pagePathWithoutData, content, pagePartials, MicronParserClass) {
+ const p = (pagePathWithoutData || "").toLowerCase();
+ if (p.endsWith(".mu")) {
+ const muParser = new MicronParserClass();
+ return muParser.convertMicronToHtml(content, pagePartials);
+ }
+ if (p.endsWith(".md")) {
+ return renderNomadMarkdown(content);
+ }
+ if (p.endsWith(".html")) {
+ return renderNomadHtmlPage(content);
+ }
+ if (p.endsWith(".txt")) {
+ return escapeNomadPlainText(content);
+ }
+ return escapeHtmlText(content);
+}
diff --git a/tests/backend/test_fuzzing.py b/tests/backend/test_fuzzing.py
index de9799bd..add445c0 100644
--- a/tests/backend/test_fuzzing.py
+++ b/tests/backend/test_fuzzing.py
@@ -25,6 +25,7 @@ from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
convert_nomadnet_string_data_to_map,
)
+from meshchatx.src.backend.page_node import normalize_page_filename
from meshchatx.src.backend.telemetry_utils import Telemeter
@@ -91,6 +92,16 @@ def test_nomadnet_field_conversion_fuzzing(field_data):
)
+@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
+@given(name=st.text(min_size=0, max_size=500))
+def test_normalize_page_filename_fuzzing(name):
+ """Fuzz mesh server page filename normalization."""
+ try:
+ normalize_page_filename(name)
+ except ValueError:
+ pass
+
+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(app_data_base64=st.one_of(st.none(), st.text(min_size=0, max_size=1000)))
def test_display_name_parsing_fuzzing(app_data_base64):
diff --git a/tests/backend/test_page_node.py b/tests/backend/test_page_node.py
index 17f080de..98b08502 100644
--- a/tests/backend/test_page_node.py
+++ b/tests/backend/test_page_node.py
@@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch
import pytest
+from meshchatx.src.backend.page_node import normalize_page_filename
+
@pytest.fixture
def node_dir():
@@ -172,6 +174,39 @@ class TestPageNodePages:
node.setup()
assert node.add_page("test.mu", "x") == "test.mu"
+ def test_add_page_accepts_md_txt_html(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.add_page("notes.md", "# x") == "notes.md"
+ assert node.add_page("readme.txt", "plain") == "readme.txt"
+ assert node.add_page("static.html", "<p>a</p>") == "static.html"
+ assert os.path.isfile(os.path.join(node.pages_dir, "notes.md"))
+ assert "/page/notes.md" in node._registered_page_paths
+
+ def test_add_page_rejects_unknown_extension(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ with pytest.raises(ValueError):
+ node.add_page("bad.exe", "x")
+
+ def test_remove_page_invalid_extension_returns_false(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.remove_page("bad.exe") is False
+
+ def test_get_page_content_invalid_extension_returns_none(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.get_page_content("bad.exe") is None
+
+ def test_setup_skips_unlisted_page_files(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ os.makedirs(node.pages_dir, exist_ok=True)
+ with open(os.path.join(node.pages_dir, "junk.exe"), "wb") as f:
+ f.write(b"x")
+ node.setup()
+ assert "/page/junk.exe" not in node._registered_page_paths
+
def test_add_page_registers_handler(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
node.setup()
@@ -357,6 +392,19 @@ class TestPageNodeLinkCallbacks:
assert len(node.active_links) == 0
+class TestNormalizePageFilename:
+ def test_accepts_allowed_extensions(self):
+ assert normalize_page_filename("a.mu") == "a.mu"
+ assert normalize_page_filename("b.md") == "b.md"
+ assert normalize_page_filename("c.txt") == "c.txt"
+ assert normalize_page_filename("d.html") == "d.html"
+ assert normalize_page_filename("x") == "x.mu"
+
+ def test_rejects_bad_extension(self):
+ with pytest.raises(ValueError):
+ normalize_page_filename("x.exe")
+
+
class TestPageNodeEdgeCases:
def test_add_page_before_running(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
diff --git a/tests/e2e/conversations-nomad-visualiser.spec.js b/tests/e2e/conversations-nomad-visualiser.spec.js
index 7df1cd19..1f1f6e32 100644
--- a/tests/e2e/conversations-nomad-visualiser.spec.js
+++ b/tests/e2e/conversations-nomad-visualiser.spec.js
@@ -19,9 +19,9 @@ test.describe("Messages (conversations)", () => {
await page.goto("/#/messages");
await page.getByText("Announces", { exact: true }).first().click();
await expect(page.getByPlaceholder(/Search .* recent announces/i)).toBeVisible({ timeout: 20000 });
- await expect(
- page.getByText(/No Peers Discovered|Waiting for someone to announce/i).first()
- ).toBeVisible({ timeout: 20000 });
+ await expect(page.getByText(/No Peers Discovered|Waiting for someone to announce/i).first()).toBeVisible({
+ timeout: 20000,
+ });
});
test("sidebar navigates between Nomad Network and Messages", async ({ page }) => {
diff --git a/tests/frontend/MessagesPageSidebarIntegration.test.js b/tests/frontend/MessagesPageSidebarIntegration.test.js
index f10b9f96..77e2bc03 100644
--- a/tests/frontend/MessagesPageSidebarIntegration.test.js
+++ b/tests/frontend/MessagesPageSidebarIntegration.test.js
@@ -27,7 +27,7 @@ vi.mock("@/js/Utils", () => ({
const ConversationViewerStub = {
name: "ConversationViewer",
- template: "<div class=\"cv-stub\"></div>",
+ template: '<div class="cv-stub"></div>',
methods: {
markConversationAsRead: vi.fn(),
},
@@ -46,11 +46,9 @@ describe("MessagesPage with MessagesSidebar integration", () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config")
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
- if (url === "/api/v1/lxmf/conversations")
- return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
- if (url === "/api/v1/lxmf/conversation-pins")
- return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
return Promise.resolve({ data: {} });
});
@@ -111,8 +109,7 @@ describe("MessagesPage with MessagesSidebar integration", () => {
},
});
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
- if (url === "/api/v1/lxmf/conversation-pins")
- return Promise.resolve({ data: { peer_hashes: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
if (url === "/api/v1/lxmf/folders") return Promise.resolve({ data: [] });
return Promise.resolve({ data: {} });
});
diff --git a/tests/frontend/NomadPageRenderer.test.js b/tests/frontend/NomadPageRenderer.test.js
new file mode 100644
index 00000000..8ebffee2
--- /dev/null
+++ b/tests/frontend/NomadPageRenderer.test.js
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import MicronParser from "../../meshchatx/src/frontend/js/MicronParser";
+import {
+ escapeNomadPlainText,
+ renderNomadHtmlPage,
+ renderNomadMarkdown,
+ renderNomadPageByPath,
+ sanitizeNomadHtmlFragment,
+} from "../../meshchatx/src/frontend/js/NomadPageRenderer";
+
+describe("NomadPageRenderer", () => {
+ it("renders markdown to safe HTML without script tags", () => {
+ const html = renderNomadMarkdown("# Title\n\n[x](javascript:alert(1))");
+ expect(html).toContain("Title");
+ expect(html.toLowerCase()).not.toContain("<script");
+ expect(html).not.toContain("javascript:");
+ });
+
+ it("normalizes ATX headings when space is missing after hashes", () => {
+ const html = renderNomadMarkdown("#Hello\n\n##There\n\n####Deep");
+ expect(html).toContain("<h1");
+ expect(html).toContain("Hello");
+ expect(html).toContain("<h2");
+ expect(html).toContain("<h4");
+ });
+
+ it("wraps markdown in nomad-markdown container", () => {
+ const html = renderNomadMarkdown("x");
+ expect(html).toContain('class="nomad-markdown"');
+ });
+
+ it("strips external http links from markdown output", () => {
+ const html = renderNomadMarkdown("[a](https://evil.example/)");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("allows nomad-style href on links after sanitization", () => {
+ const html = renderNomadMarkdown("[local](:page/other.mu)");
+ expect(html).toContain(":page/other.mu");
+ });
+
+ it("sanitizes HTML documents and removes script", () => {
+ const html = renderNomadHtmlPage(
+ "<!DOCTYPE html><html><head><style>body{color:red}</style></head><body><p>Hi</p><script>x</script></body></html>"
+ );
+ expect(html.toLowerCase()).not.toContain("<script");
+ expect(html).toContain("Hi");
+ expect(html).toContain("nomad-html-root");
+ expect(html).toContain(".nomad-html-root");
+ expect(html).toContain("color:red");
+ });
+
+ it("removes iframe and external stylesheet references from HTML", () => {
+ const html = renderNomadHtmlPage(
+ '<html><head><link rel="stylesheet" href="https://evil.example/s.css"/></head><body><iframe src="https://x"></iframe><p>x</p></body></html>'
+ );
+ expect(html.toLowerCase()).not.toContain("iframe");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("escapes plain text pages", () => {
+ const out = escapeNomadPlainText("<script>bad</script>");
+ expect(out).not.toContain("<script>");
+ expect(out).toContain("<script>");
+ });
+
+ it("renderNomadPageByPath picks format by extension", () => {
+ const dest = "a".repeat(32);
+ const muHtml = renderNomadPageByPath(`${dest}:/page/x.mu`, "Hello mesh", {}, MicronParser);
+ expect(typeof muHtml).toBe("string");
+ expect(muHtml.length).toBeGreaterThan(0);
+ expect(renderNomadPageByPath("/page/a.md", "# T", {}, MicronParser)).toContain("T");
+ expect(renderNomadPageByPath("/page/a.txt", "a<b>", {}, MicronParser)).toContain("<");
+ expect(renderNomadPageByPath("/page/a.html", '<p class="x">z</p>', {}, MicronParser)).toContain("z");
+ });
+
+ it("sanitizeNomadHtmlFragment handles arbitrary strings without throwing", () => {
+ expect(() => sanitizeNomadHtmlFragment("<div>ok</div>")).not.toThrow();
+ });
+
+ it("fuzzing: handles random strings in markdown and HTML sanitization", () => {
+ for (let i = 0; i < 200; i++) {
+ let s = "";
+ const len = Math.floor(Math.random() * 800);
+ for (let j = 0; j < len; j++) {
+ s += String.fromCharCode(Math.floor(Math.random() * 65536));
+ }
+ expect(() => renderNomadMarkdown(s)).not.toThrow();
+ expect(() => sanitizeNomadHtmlFragment(s)).not.toThrow();
+ expect(() => renderNomadHtmlPage(s)).not.toThrow();
+ }
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────